Skip to content

docs(design): wire observability and debug host - #315

Open
decrypto21 wants to merge 1 commit into
mainfrom
nidish/debugger-rfc
Open

docs(design): wire observability and debug host#315
decrypto21 wants to merge 1 commit into
mainfrom
nidish/debugger-rfc

Conversation

@decrypto21

@decrypto21 decrypto21 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Specifies the wire observability layer for @parity/truapi: where the tap may live, what a sink may and may not do, the envelope and wire-contract identity carried with it, how frames are correlated, how each surface is confined, and how enablement is read and reported. The doc fixes the contract rather than walking through the implementation.

Rewritten as a specification per @TarikGul's review: 54 normative statements across 12 sections. The version reviewed on 28 Jul had none, which is what allowed it to drift from the code.

On "slim this down": the sections called out as fluff are gone — the testing section, the examples/relay-server.mjs reference (deleted in #295), the ?debug query-param gate, the "dead-code-eliminable" claim — and rationale in §3, §6, §7 and §8 is compressed by roughly 12 lines. §9 is new and three requirements were added, so the net is 285 → 312 lines. §6's confinement rationale and §8(C) are the remaining candidates if a further cut is wanted.

Changes in this push:

  • §7 asserted a mechanism the code did not have. It said the bundler replaces the DEV condition with a literal. The shipped code read the flag through an alias, which no bundler substitutes, so the tap was disabled in every build and the production guarantee held by accident. Fixed in feat(truapi-server): host-side wire debug tap and debugger #295; the doc now requires the bare import.meta.env.DEV token, since violating it fails silently in both directions.

  • @Imod7's gating comment is resolved at the root. There is no query-param gate and no window.__truapiWireDebugger__ global in the design. Enablement is build-time only; the runtime switch controls where to dial, never whether to decode.

  • @TarikGul on dead-code elimination: the claim is replaced by a testable property — absent from the production bundle. A production build of dot.li contains zero occurrences of the inspector's own strings.

  • @pgherveou on native apps: §9 no longer assumes a browser. Enablement is host-specific (per-origin store for a browser host, injected value for a native one) and the requirement covers both.

  • §7's "the package is private" is replaced by the property that does the work: absent from the production bundle. Publishing does not weaken the guarantee.

  • §8(A) specified a CLI/REPL that was dropped; the HTTP endpoints are the shared contract. §8(B) said the embed carries less chrome than the standalone; it carries the same, over one renderer and one engine.

  • §3 requires every producer to stamp identity, including a same-page tee. A tap on the far side of a worker boundary cannot read the core's hash by construction, so the core must expose it outward. Without this the two surfaces are asymmetric and the embedded one can never decode.

  • §9 is new: enablement, and saying so. A host must report whether it dialled and from what source. Silence is indistinguishable from success: the debugger's own viewer holds a socket, so an empty board with a live socket reads as a broken debugger rather than a host that was never enabled.

  • Doc: docs/design/wire-observability-debug-host.md

  • Implementation: feat(truapi-server): host-side wire debug tap and debugger #295

@decrypto21
decrypto21 requested review from a team July 25, 2026 06:04
@decrypto21
decrypto21 force-pushed the nidish/debugger-rfc branch 2 times, most recently from 727e0cc to cccd69d Compare July 25, 2026 06:10
@decrypto21 decrypto21 changed the title docs(rfc): add 0022 wire observability and debug host docs(design): wire observability and debug host Jul 25, 2026
@decrypto21
decrypto21 removed the request for review from a team July 25, 2026 06:11
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated
Comment thread docs/design/wire-observability-debug-host.md Outdated

@TarikGul TarikGul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think generally the content of the doc is good - and the direction is nice. But I think we should reframe it more like a specification. Currently its very easy for the doc to get out of sync with the implementation PR.

I think we can really slim this down and take out some of the unnecessary sections that dont add much to the actual core design of what we are building.

@pgherveou

pgherveou commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Two points on the design. Both are about where the debugger lives.

1. Topology: the host dials the debugger

The doc's model is a passive observe callback on createTransport (L78), with the debugger notified
off to the side (L65-74), plus a separate relay service to reach another process. I'd replace both
with one rule: the host opens a WebSocket to the debugger app and streams frames to it.

This is not a preference, it's forced. Only the inside can initiate:

  • ws_bridge binds 127.0.0.1 on the device. A debugger app on the dev machine cannot reach it,
    and cannot dial into the device. (It works in the iOS Simulator only because the simulator shares
    the Mac's network stack, which is an accident of the simulator, not a design.)
  • In web, nothing outside the browser can dial into a Worker.

So the debugger app is always the server, and every host dials outward:

 WEB                                      NATIVE / DESKTOP

 ┌─ browser ──────────────────┐           ┌─ device ───────────────────┐
 │  product (iframe)          │           │  product (webview)         │
 │      │ MessagePort         │           │      │ ws loopback         │
 │      ▼                     │           │      ▼                     │
 │  host worker ── tap        │           │  ws_bridge ── tap          │
 │      │        rust wasm    │           │      │       rust core     │
 └──────┼─────────────────────┘           └──────┼─────────────────────┘
        │ ws, dialed outward                     │ ws, dialed outward
        └──────────────┬─────────────────────────┘
                       ▼
           ┌──────────────────────┐
           │    debugger app      │   ws server on the dev machine
           └──────────────────────┘
A CALL, END TO END

 product                host edge               rust core           debugger app
(iframe / webview)        (tap)                                       (remote)
    │                       │                       │                     │
    │  getAccount()         │                       │                     │
    ├─ frame{ p:1, id=22 } ─▶                       │                     │
    │                       ├┈┈┈┈┈┈┈┈┈┈┈┈ { dir: out, frame } ┈┈┈┈┈┈┈┈┈┈┈┈▶
    │                       ├ delivered immediately ▶                     │
    │                       │                       │                     │  LOG  ▲ out  p:1
    │                       │                       │  ctx.requestId = "p:1"
    │                       ◀─ frame{ p:1, id=23 } ─┤                     │
    │                       ├┈┈┈┈┈┈┈┈┈┈┈┈ { dir: in, frame } ┈┈┈┈┈┈┈┈┈┈┈┈┈▶
    ◀ delivered immediately ┤                       │                     │
    │  Ok(response)         │                       │                     │
    │                       │                       │                     │  LOG  ▼ in   p:1

Two properties to state as invariants, neither currently in the doc:

  • In the path, not in the critical path. The tap sees every frame because it is carrying them, and
    it forwards before it emits. A slow, absent, or crashed debugger cannot stall a session. Only the
    trace is lost.
  • Emit, never wait. No reply is read from the debugger socket. That is what makes the first
    property true.

Worth noting that the second invariant is also the extension point. If we later want mocking or
mutation, the socket and the envelope stay as they are and the tap starts reading a reply on the
frames it cares about: deliver unchanged is today's behaviour, deliver modified is mutation,
respond is a mock. Nothing about the topology or the event shape has to change, which is the reason
to be comfortable shipping the one-way version first.

What this removes from the current design: the relay service (RelayRouter, createRelayProvider,
the { v, role, sessionId, productId, frame } envelope, join-order buffering, the Bun reference
server) is unnecessary, because the debugger app is the server. The envelope collapses to:

 host → debugger    { channelId, dir: "out" | "in", frame: bytes }

Two things this forces that the doc should state. The debugger URL must be configurable, not
loopback-derived: with the app on a device and the debugger on a Mac, 127.0.0.1 is wrong and the
link is LAN.

And that LAN hop is why wss:// is the right call. It keeps
hosts/ios/polkadot-app/Info.plist as it is (an empty NSAppTransportSecurity already requires TLS,
and wss:// satisfies it, so no exception is needed) and it means the trace does not cross the
office network in the clear. The cost moves to certificates: iOS still evaluates trust, so a
self-signed debugger cert is rejected until its CA is installed and switched on under Settings →
General → About → Certificate Trust Settings. Worth naming that as a setup step in the doc, since
"use wss" reads as free and isn't.


2. The tap belongs in truapi-server, behind a sink trait

The doc puts the seam in the TypeScript transport (js/packages/truapi/src/client.ts) and turns it on
from the product side via @parity/truapi/sandbox reading ?debug=wire (L244-252). That is why the
doc has to claim host-agnosticism (L46-47) while shipping a browser-only, product-package integration,
and why it needs a dead-code-elimination argument (L129-131) to keep the decode path out of production
bundles.

Put it in the Rust host instead. truapi-server already has exactly two choke points, and both web
and native funnel through them
:

Direction Choke point
inbound (product → core) ProductRuntime::receive_frame()host_core.rs:422
outbound (core → product) FrameSink::emit_frame()host_core.rs:40

Native reaches these through ws_bridge.rs (WsFrameSink), web through wasm.rs:851. So one tap
implementation covers both platforms, and nothing in @parity/truapi changes at all. That is the test
for whether the product is genuinely untouched, and the current design fails it.

A sink trait, not a hardcoded socket

Worth defining the flush target as a trait rather than wiring a WebSocket into the core, both to keep
the transport out of truapi-server and because wire frames are not the only thing worth observing.
dotli's own debugger also surfaces host-internal events like SSO, and those have no wire frame to hang
off. An enum event keeps room for them without reopening the design:

/// Dev-only sink for host debug events. A host that does not enable the
/// debugger leaves it unset and the tap is inert.
pub trait DebugSink: Send + Sync {
    /// Hand one event to the sink. Fire-and-forget by construction: it cannot
    /// block the frame path and cannot fail the operation that produced it.
    fn emit(&self, event: DebugEvent);
}

/// One observable host event.
pub enum DebugEvent {
    /// A SCALE wire frame crossing a product channel.
    Frame {
        /// Which product channel on this host.
        channel_id: ChannelId,
        /// Product → core, or core → product.
        dir: FrameDirection,
        /// Untouched `ProtocolMessage` bytes; the debugger app decodes.
        bytes: Vec<u8>,
    },
}

Comment thread docs/design/wire-observability-debug-host.md Outdated
@decrypto21
decrypto21 force-pushed the nidish/debugger-rfc branch 2 times, most recently from 13048f9 to 4f5c8f7 Compare July 28, 2026 09:04
@decrypto21

Copy link
Copy Markdown
Contributor Author

Moved the wire debug tap into the Rust host (truapi-server's DebugSink) - tapping both frame choke points

@decrypto21
decrypto21 force-pushed the nidish/debugger-rfc branch from b509e63 to 723fdfd Compare August 24, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants